Jerry's Log

Spring Bean

contents

1. 스프링 빈(Spring Bean)이란?

일반적인 자바에서는 new를 사용해 객체를 만듭니다.

UserService service = new UserService(); // 개발자가 직접 관리함

스프링에서 빈(Bean) 은 스프링 IoC(제어의 역전) 컨테이너가 인스턴스화하고, 조립하고, 관리하는 자바 객체를 말합니다.

@Service
public class UserService { ... } // 스프링이 관리함

즉, 객체 생성의 제어권을 개발자(나)에게서 스프링으로 넘기는 것입니다.


2. 어디에 할당되나요? (메모리와 위치)

이 질문은 두 가지 관점에서 봐야 합니다.

A. 물리적 메모리 (JVM 힙 영역)

다른 자바 객체들과 마찬가지로, 스프링 빈도 자바 힙(Heap) 메모리 안에 존재합니다.

B. 논리적 위치 (컨테이너의 캐시)

스프링은 힙에 있는 이 객체들의 주소(참조)를 ApplicationContext 내부에 보관합니다. 특히 싱글톤 빈의 경우 내부적으로 Map에 저장됩니다.

// DefaultSingletonBeanRegistry.java (스프링 코어 깊은 곳에 있는 코드)
private final Map singletonObjects = new ConcurrentHashMap<>(256);

여러분이 빈을 요청하면, 스프링은 이 Map을 조회해서 주소를 찾아 건네줍니다.


3. 어떻게 생성되나요? (라이프사이클 과정)

이 부분이 가장 중요합니다. 애플리케이션을 시작할 때(예: SpringApplication.run), 컨테이너는 빈을 만들기 위해 복잡한 과정을 거칩니다.

싱글톤 빈 하나가 생성되는 단계별 흐름은 다음과 같습니다.

1단계: 빈 정의 스캔 (Bean Definition Scanning)

먼저 스프링은 클래스들을 스캔합니다 (@Component, @Service, @Bean 등을 찾음). 그리고 BeanDefinition 객체를 만듭니다. 이것은 아직 빈이 아닙니다. 나중에 빈을 어떻게 만들지 적어둔 "설계도" 혹은 "레시피"입니다.

2단계: 인스턴스화 (Instantiation)

스프링은 자바 리플렉션(Reflection)을 사용해 껍데기 객체를 생성합니다.

3단계: 의존성 주입 (Dependency Injection)

스프링은 @Autowired가 붙은 필드나 세터(Setter)를 확인합니다.

4단계: Aware 인터페이스 호출

만약 빈이 특별한 인터페이스(예: BeanNameAware, ApplicationContextAware)를 구현했다면, 스프링이 이를 호출해 줍니다.

5단계: BeanPostProcessor (초기화 전 - Before Initialization)

이곳은 중간 가로채기 지점입니다. 스프링은 모든 BeanPostProcessor를 돌면서 postProcessBeforeInitialization을 호출합니다.

6단계: 초기화 (Initialization)

이제 스프링은 초기화 로직을 실행합니다.

  1. 메서드에 @PostConstruct 가 붙어 있다면 지금 실행됩니다.
  2. 빈이 InitializingBean을 구현했다면 afterPropertiesSet()이 실행됩니다.

7단계: BeanPostProcessor (초기화 후 - After Initialization) - 마법이 일어나는 곳

스프링은 postProcessAfterInitialization을 호출합니다.

8단계: 사용 준비 완료

이제 빈이 singletonObjects Map에 저장됩니다. 다른 빈에 주입될 준비가 끝났습니다.


4. 어떻게 사용되나요?

빈이 컨테이너에 들어가면, 의존성 주입(DI) 을 통해 사용할 수 있습니다. 주입받는 방법은 크게 3가지입니다.

  1. 생성자 주입 (권장)

가장 좋은 방법입니다. 의존성 없이는 빈이 생성될 수 없도록 강제합니다.

@Service
public class OrderService {
    private final UserService userService;

    // 스프링이 이걸 보고, "UserService"를 Map에서 찾아와서 넣어줍니다.
    public OrderService(UserService userService) {
        this.userService = userService;
    }
}

2. 필드 주입 (비권장)

@Autowired
private UserService userService; // 스프링이 리플렉션을 써서 private 필드에 억지로 넣습니다.

왜 피해야 할까요? 테스트가 어렵습니다(생성자가 없어서 Mock 객체를 넣을 수 없음). 의존성이 숨겨집니다.

  1. 세터(Setter) 주입

선택적인(Optional) 의존성일 때 사용합니다.


5. 빈 스코프 (존재의 규칙)

@Scope를 사용해 라이프사이클을 조정할 수 있습니다.

스코프 설명
Singleton (기본값) ApplicationContext 당 단 하나의 인스턴스만 존재합니다. 모든 요청이 똑같은 객체를 공유합니다. 주의: 상태값(예: user_id)을 필드에 저장하면 안 됩니다. 다른 사용자가 덮어씁니다!
Prototype 요청할 때마다 새로운 인스턴스가 생성됩니다.
Request (웹) HTTP 요청 하나당 하나의 빈이 생성되고, 응답이 나가면 사라집니다.
Session (웹) HTTP 세션(사용자) 하나당 하나의 빈이 유지됩니다.

6. 어떻게 소멸되나요?

애플리케이션이 종료될 때 (ctx.close()):

  1. @PreDestroy: 이 어노테이션이 붙은 메서드를 호출합니다. DB 연결을 끊거나 파일 스트림을 닫는 등 리소스 해제를 여기서 합니다.
  2. DisposableBean: 인터페이스를 구현했다면 destroy()가 호출됩니다.
  3. 메모리 해제: singletonObjects Map에서 참조가 제거되고, 힙에 있던 객체는 결국 가비지 컬렉터(GC)가 수거해 갑니다.

주니어 개발자를 위한 요약 체크리스트

  1. 정의: 스프링이 관리하는 POJO(평범한 자바 객체)다.
  2. 저장소: 힙(Heap) 메모리에 살고, ApplicationContext 내부의 Map에 주소가 적혀 있다.
  3. 생성 과정: 스캔 -> 인스턴스화 -> 의존성 주입 -> @PostConstruct -> 프록시 생성(필요시).
  4. 사용: 생성자 주입을 통해 받아서 쓴다.

문제 상황: 순환 참조 (Circular Dependency)

두 개의 빈, Service AService B가 있다고 가정해 봅시다.

@Service
public class ServiceA {
    @Autowired
    private ServiceB serviceB;
}

@Service
public class ServiceB {
    @Autowired
    private ServiceA serviceA;
}

논리적 교착 상태(Deadlock):

  1. 스프링이 A를 생성하려고 합니다.
  2. A가 말합니다. "B가 필요해요."
  3. 스프링은 A를 멈추고 B를 생성하러 갑니다.
  4. B가 말합니다. "A가 필요해요."
  5. 스프링은 A를 찾지만, A는 아직 다 만들어지지 않았습니다! -> 무한 루프 / 스택 오버플로우 발생.

해결책: 3개의 캐시 (The Three Caches)

스프링은 **인스턴스화(Instantiation, 메모리에 껍데기 객체 생성)**와 초기화(Initialization, 의존성 주입) 단계를 분리함으로써 이 문제를 해결합니다.

스프링은 내부적으로 DefaultSingletonBeanRegistry라는 곳에 위치한 3개의 Map(캐시)을 사용합니다.

레벨 캐시 이름 저장되는 것
1단계 singletonObjects 완전히 초기화된 빈. 당장 사용할 수 있는 상태입니다. 스프링은 여기를 가장 먼저 확인합니다.
2단계 earlySingletonObjects 완성되지 않은 빈. 인스턴스화(메모리 할당)는 되었지만, 의존성 주입은 아직 안 된 상태입니다. 오직 순환 참조를 해결하기 위해서만 사용됩니다.
3단계 singletonFactories 빈(혹은 빈의 프록시)을 조기에 생산할 수 있는 **팩토리(람다 식)**가 저장됩니다.

단계별 실행 흐름 (Step-by-Step)

여러분이 ServiceA를 요청했을 때 어떤 일이 벌어지는지 추적해 보겠습니다.

1단계: 'A' 생성 시작

  1. 스프링이 1단계 캐시를 확인합니다. ServiceA는 없습니다.
  2. 스프링이 ServiceA인스턴스화합니다 (new ServiceA() 호출).
    • 이제 힙 메모리에 "빈 껍데기" 객체가 생겼습니다. 데이터는 아직 없습니다.
  3. 결정적 단계: 스프링은 A를 만들기 위한 팩토리3단계 캐시(singletonFactories)에 넣습니다.
    • 마치 이렇게 말하는 것과 같습니다: "내가 다 만들기 전에 누가 급하게 A를 찾으면, 이 팩토리를 돌려서 A의 주소(참조)를 가져가세요."
  4. A에게 의존성을 주입하려고 보니 ServiceB가 필요함을 알게 됩니다.

2단계: 'B' 생성 시작 (A가 필요로 하므로)

  1. 스프링은 A 생성을 잠시 멈추고 ServiceB 생성을 시작합니다.
  2. 1단계 캐시를 확인합니다. B는 없습니다.
  3. ServiceB인스턴스화합니다.
  4. B를 위한 팩토리3단계 캐시에 넣습니다.
  5. B에게 의존성을 주입하려고 보니 ServiceA가 필요함을 알게 됩니다.

3단계: B의 A에 대한 의존성 해결

  1. B가 ServiceA를 요청합니다.
  2. 1단계 확인: 없음 (A는 아직 미완성).
  3. 2단계 확인: 없음.
  4. 3단계 확인: 찾았다! A를 위한 팩토리가 있습니다 (1-3단계에서 넣어둠).
  5. 동작:
    • 스프링이 팩토리를 실행합니다. 팩토리는 "빈 껍데기" A(혹은 A의 프록시)의 주소를 반환합니다.
    • A를 3단계에서 -> 2단계(earlySingletonObjects)로 이동시킵니다.
    • A를 3단계에서 제거합니다.
  6. 주입: 미완성 상태인 A가 B에 주입됩니다.
    • 참고: B는 이제 A를 가리키는 참조를 갖게 되었습니다. A가 비어있더라도 메모리 주소는 유효합니다.

4단계: 'B' 완성

  1. B는 모든 의존성을 갖췄습니다 (A의 참조를 가짐).
  2. B의 초기화가 완료됩니다 (@PostConstruct 등 실행).
  3. B가 3단계에서 -> 1단계(singletonObjects)로 이동합니다.
  4. B는 이제 완전히 준비되었습니다.

5단계: 'A' 완성

  1. 스프링은 멈춰뒀던 A로 돌아옵니다 (1-4단계 시점).
  2. A는 방금 완성된 ServiceB를 주입받습니다.
  3. A의 초기화가 완료됩니다.
  4. A가 2단계에서 -> 1단계로 이동합니다.

결과: 두 빈 모두 서로를 참조하고 있으며, 스택 오버플로우 없이 정상적으로 생성되었습니다.


심화 질문: 왜 2단계가 아니라 3단계인가요?

"그냥 껍데기 객체를 바로 2단계에 넣으면 되지, 굳이 왜 팩토리(3단계)가 필요한가요?" 라는 의문이 들 수 있습니다.

정답은 AOP(관점 지향 프로그래밍)와 프록시(Proxy) 때문입니다.

만약 ServiceA@Transactional이 붙어 있다면, 스프링은 "원본 A"가 아니라 A를 감싼 프록시 A를 컨테이너에 넣어야 합니다.

만약 2단계만 쓴다면 생기는 문제:

보통 스프링은 객체 생성의 맨 마지막 단계(라이프사이클의 8단계)에서 프록시를 만듭니다.

만약 원본 객체를 미리 2단계에 넣어버리면:

해결책 (3단계 팩토리):

3단계에 있는 팩토리는 다음과 같은 로직을 갖고 있습니다: "내가 얘를 프록시로 감싸야 하나?"

캐시 요약

  1. 3단계 (singletonFactories): "누가 급하게 찾을 때를 대비해서, 참조(필요하면 프록시)를 만들어주는 람다식을 여기 보관함."
  2. 2단계 (earlySingletonObjects): "누가 급하게 찾아서 내가 이미 만들었어. 팩토리를 두 번 돌리지 않게 여기다 만들어둔 참조(원본 혹은 프록시)를 캐싱함."
  3. 1단계 (singletonObjects): "나는 완전히 생성되고 주입도 끝난 완성품임."

이 방식이 실패하는 경우?

이 메커니즘은 Setter 주입이나 Field 주입에서만 동작합니다.

생성자 주입(Constructor Injection) 에서는 실패합니다.

public ServiceA(ServiceB b) { ... } // 실패!

이유:

"빈 껍데기" 객체를 3단계에 넣으려면 일단 객체를 생성(인스턴스화)해야 합니다. 하지만 생성자를 호출하려면 ServiceB가 필요하므로, 객체조차 만들 수 없는 "닭이 먼저냐 달걀이 먼저냐" 상황이 됩니다.

해결 방법: 생성자 파라미터에 @Lazy를 사용하세요.

public ServiceA(@Lazy ServiceB b) { ... }

이렇게 하면 임시 프록시를 먼저 넣어줘서 생성자가 통과되게 만듭니다.

references